Skip to content

Allow web URL imports via the share extension#1516

Open
matalvernaz wants to merge 11 commits into
TortugaPower:developfrom
matalvernaz:feature/share-url-import
Open

Allow web URL imports via the share extension#1516
matalvernaz wants to merge 11 commits into
TortugaPower:developfrom
matalvernaz:feature/share-url-import

Conversation

@matalvernaz

@matalvernaz matalvernaz commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Lets users share an http(s) URL pointing at a media file (e.g. an mp3 served by a self-hosted yt-dlp UI, a podcast episode link, a casting tool's direct download URL) into BookPlayer through the iOS share sheet. Today the share extension is hidden from the share sheet for URL-only payloads, since its activation rule only matches public.audio / public.folder / public.movie / com.pkware.zip-archive attachments — and even if it weren't, the rest of the pipeline assumed file URLs that the extension could copyItem straight into the shared folder.

Architecture

The share extension's NSExtensionActivationRule accepts public.url in addition to its existing UTIs. When a web URL arrives, the extension downloads it via a background URLSession with sharedContainerIdentifier set to the app group, then immediately calls completeRequest(returningItems: nil) — the share sheet dismisses without blocking on the download.

Two delegate paths cover the lifecycle:

  • BackgroundDownloadCoordinator (in ShareViewController.swift): retained on the running view controller for as long as the extension's process is alive. iOS routes the URLSession(_:downloadTask:didFinishDownloadingTo:) callback to this delegate when the download completes while the extension is still running (typical for small files that finish in seconds). The coordinator moves the temp file into DataManager.getSharedFilesFolderURL().
  • BackgroundShareDownloadDelegate (in AppDelegate.swift): the fallback for downloads large enough to outlive the extension. iOS launches the main app via application(_:handleEventsForBackgroundURLSession:completionHandler:), which recreates a session under the same Constants.shareExtensionBackgroundSessionIdentifier and lets this delegate move the temp file into the shared folder.

Either way, the file lands in the app group's shared folder, and BookPlayer's existing ImportManager pipeline picks it up on the next foreground via notifyPendingFiles() and the DirectoryWatcher already wired to that folder. The user sees BookPlayer's standard import-and-place flow, identical to AirDrop.

File-URL shares (AirDrop, Files, document picker) are unchanged: the extension still copies them directly into the shared folder.

URL filtering

To avoid showing BookPlayer in the share sheet for arbitrary web pages it couldn't actually download as audio, web URLs are pre-filtered by path extension against a small allow-list of audio / movie / archive formats: mp3 m4a m4b aac flac ogg opus wav wma aiff aif caf mp4 m4v mov zip. File URLs are accepted unconditionally — the import pipeline handles MIME sniffing.

Why background URLSession + delegate, and not the obvious alternatives

Earlier iterations tried each of these and ran into problems documented in the commit history:

  • Open the host app via bookplayer://download?url=… URL scheme. Doesn't work on iOS 18+: the responder-chain openURL: walk no-ops with a system warning, and NSExtensionContext.open(_:completionHandler:) is documented Today-widget-only and confirmed to return false for share extensions.
  • Foreground URLSession.downloadTask inside the share extension, then completeRequest. Works, but the share UI hangs for the duration of the download.
  • Queue the URL into shared UserDefaults, drain in the main app via SingleFileDownloadService. Required main-app code to fire on warm-foreground via scenePhase, raced with DocumentFolderWatcher to produce duplicate import dialogs, and ultimately couldn't be made to land files reliably without bypassing significant chunks of the existing pipeline.
  • Background URLSession in the extension with delegate: nil. This was the most subtle failure. The session created the download task and nsurlsessiond finished it correctly — but iOS only escalates background-session completion to the main app's matching-identifier session via handleEventsForBackgroundURLSession when the owning process is no longer running. For small downloads that complete in a few seconds, the share extension's process was still alive when the completion fired, so iOS routed the didFinishDownloadingTo event to the alive process's session — which had no delegate — and silently discarded the temp file. The two-delegate setup in this PR covers both lifecycles.

Test plan

  • Compile-check on iOS device target
  • End-to-end share from Safari to a real https://…/song.mp3 URL on iOS 26: file lands in shared folder, ImportManager surfaces it on next foreground, review and placement dialogs fire as for AirDrop, file imports into the library
  • Confirmed via idevicesyslog that the share extension's BackgroundDownloadCoordinator fires didFinishDownloadingTo and moves the temp file
  • File-URL shares (AirDrop, Files) unchanged — covered structurally by items.filter { $0.isFileURL } keeping the original copyItem path
  • Long-running download outlives the extension — falls through to the main-app BackgroundShareDownloadDelegate. Believed-correct from spec; not exercised in testing because typical share-sheet payloads are small.

Known limitations

  • iOS share-extension activation rules can predicate on UTI only, not URL content, so we filter inside the extension by path extension. BookPlayer will appear in the share sheet for any URL whose path happens to end in one of the allow-listed extensions even if the host is unrelated (rare in practice). Pages whose URL has no media-y extension won't show BookPlayer at all.

Lets users share an http(s) URL pointing at a media file (e.g. an mp3
served by a self-hosted yt-dlp / casting tool / podcast feed) into
BookPlayer through the iOS share sheet. Today the share extension is
hidden from the share sheet for URL-only payloads because the
activation rule only matches public.audio / public.folder /
public.movie / com.pkware.zip-archive attachments.

Two changes:

- Add public.url to the share extension's NSExtensionActivationRule
  so URL shares can reach ShareViewController in the first place.

- In ShareViewController, branch saveSharedItems() on isFileURL: file
  URLs continue to be copied into the shared folder as before; web
  URLs are forwarded to the main app via bookplayer://download?url=...
  so the existing SingleFileDownloadService handles the actual fetch
  with the app's normal networking stack (Command.download and
  ActionParserService.handleDownloadAction already exist).

To avoid showing BookPlayer in the share sheet for arbitrary web
pages it could not actually download, web URLs are pre-filtered by
path extension against a small allow-list of audio / movie / archive
formats. File URLs (AirDrop, Files, document picker) are unchanged
and accepted unconditionally.

The extension hands off to the host app via the responder-chain
walk + openURL: selector pattern used by 1Password, Pocket, and
similar share extensions.
The previous responder-chain `openURL:` walk no longer reaches a live
UIApplication from a share-extension context on iOS 18, so tapping
Done in the share sheet for a URL just dismissed the extension and
left the user back in Safari without launching BookPlayer.

NSExtensionContext.open(_:completionHandler:) is documented as
unavailable for share extensions, but it actually works on iOS 14+
and is the only reliable path on iOS 18+. Switch to it as the primary
method, keeping the responder-chain walk as a fallback for older OSes
where the extensionContext path returns false synchronously.

Also defer completeRequest until after the URL handoff completes —
completing first dismisses the extension before iOS dispatches the
in-flight openURL, dropping it on the floor.
Current iOS makes the previous design unworkable: share extensions
cannot launch their host app at all. The responder-chain openURL:
walk silently no-ops with a system log warning, and the
NSExtensionContext.open API is documented Today-widget-only and
returns false for share extensions in practice. So the
bookplayer://download?url=... handoff plan never reaches the main
app.

New approach: deposit the final file straight into the app group's
shared folder ourselves. BookPlayer's main app already watches that
folder via DirectoryWatcher in LibraryRootView, and runs
ImportManager.notifyPendingFiles() on appear -- the same import
pipeline that handles AirDropped audio. Web URLs become an
additional contributor to that pipeline rather than triggering a
separate download flow in the main app.

The share extension now downloads the URL with a foreground
URLSession.downloadTask while its loading UI is showing, then moves
the result (with the server-suggested filename when available) into
the shared folder before completing the extension request. Rejects
non-2xx HTTP responses so a 404 HTML body never gets imported as
audio. Foreground session is sufficient for typical share-sheet
payloads (audio tracks, episodes -- tens of MB). A background
URLSession with sharedContainerIdentifier is the right upgrade if
multi-GB single files become a use case.
Build 5 worked but had two unwanted UX wrinkles: the share extension
held its loading spinner until the URLSession download finished
(slow on big files / slow networks), and arriving via the AirDrop
file path triggered a second import-confirmation prompt inside
BookPlayer.

Better approach: stop downloading inside the share extension at all,
and reuse SingleFileDownloadService -- the same in-app URL-download
engine used by AudiobookShelf and Jellyfin integrations, with its
own progress UI and direct-into-library output.

Changes:

- Share extension writes the queued web URLs into the app group's
  shared UserDefaults under Constants.UserDefaults.pendingShareDownloadURLs,
  then dismisses immediately. File-URL shares (AirDrop, Files) keep
  their existing copy-into-shared-folder behaviour.
- LibraryRootView.handleLibraryLoaded() drains that key after the
  existing pendingURLActions drain and hands the URLs to
  singleFileDownloadService.handleDownload(_:). The download then
  runs in BookPlayer's normal UI and the file lands directly in
  Documents (the library) -- no separate import confirmation.
- New Constants.UserDefaults.pendingShareDownloadURLs key documents
  the shared-defaults contract between extension and main app.

The extension still requires the user to open BookPlayer for the
download to start (same as today: the AirDrop import pipeline is
also gated on foreground), but the share-sheet dismiss is now
instant and the in-app experience matches the manual "download
from URL" feature users already know.
Build 6 missed the warm-foreground case: handleLibraryLoaded only
fires once per LibraryRootView lifecycle (gated behind isFirstLoad),
so when BookPlayer was already in memory and the user shared to it
from another app, the queued URL sat in shared UserDefaults forever.

Fix: extract the drain into a standalone idempotent function and
also trigger it from the existing .onChange(of: scenePhase) handler
when scenePhase becomes .active. That covers warm foreground (the
production failure mode); cold launch keeps working via
handleLibraryLoaded as before.

Includes NSLog instrumentation in the drain so device-log capture
makes future regressions trivially observable.

Verified end-to-end on iOS 26 simulator: URL written to shared
UserDefaults, BookPlayer cold-launched, NSLog confirms drain fires
with 1 URL and hands it to singleFileDownloadService, plist key
cleared after launch.
Final architecture for the web-URL share path. Earlier iterations
ranged through opening the host app via openURL: (broken on iOS 18+),
queueing into shared UserDefaults for the main app to drain (race
conditions and lost events), and downloading inline in the share
extension's foreground (worked but blocked the share UI for the
duration of the download). This commit lands the version that
actually works in the way iOS expects.

The share extension creates a background URLSession with
sharedContainerIdentifier set to the app group, kicks off a
downloadTask, and immediately returns from completeRequest -- the
share UI dismisses without waiting on bytes. iOS' nsurlsessiond
keeps the transfer running in its own daemon process.

Two delivery paths handle the eventual completion:

1. If the share extension's process is still alive when the download
   completes (typical for small files that finish in seconds), iOS
   routes the URLSession completion to *the alive process's session*.
   The previous "delegate: nil" version silently dropped the temp
   file in this case because there was no delegate to claim it. Now
   ShareViewController retains a BackgroundDownloadCoordinator that
   moves the temp file into DataManager.getSharedFilesFolderURL().

2. If iOS terminates the extension before the download completes
   (large files, slow networks), the transfer keeps running in
   nsurlsessiond. When it finishes, iOS launches the main app via
   application(_:handleEventsForBackgroundURLSession:completionHandler:);
   AppDelegate hands off to BackgroundShareDownloadDelegate, which
   recreates the same-identifier session, claims the temp file, and
   moves it into the same shared folder.

Either way the file lands in the app group's shared folder, where
the existing AirDrop-style import pipeline (notifyPendingFiles +
DirectoryWatcher + ImportManager) picks it up on the next foreground
and runs it through BookPlayer's standard review-and-place dialogs.

File URL shares (AirDrop, Files app, document picker) keep their
original synchronous-copy behaviour; only web URLs go through the
background-session path.

Includes the abandoned LibraryRootView UserDefaults-drain code being
cleaned up from earlier iterations, plus
Constants.shareExtensionBackgroundSessionIdentifier as the documented
shared key between extension and main app.
@matalvernaz

Copy link
Copy Markdown
Contributor Author

Caught up to current develop. Same six commits as before, just rebased — nothing else to see.

@matalvernaz matalvernaz force-pushed the feature/share-url-import branch from 20c522a to 858a00a Compare May 5, 2026 10:32
The share extension and the main app's BackgroundShareDownloadDelegate
both used try? / log-only when their copy/move operations failed, and
the HTTP non-2xx branch returned silently. A blind user (or any user)
sharing into BookPlayer had no way to know a download silently dropped
because the disk was full, the server returned an error page, or the
destination filename collided.

Add ShareImportFailureStore: a small JSON file in the App Group
container that both processes can append to. UserDefaults synchronization
between extension and host is unreliable, so a deliberate atomic-write
file is more robust.

Both delegates and the synchronous extension copy now record failures
with a specific, actionable message (filename + underlying error, or
HTTP status code). LibraryRootView drains the store on every
scenePhase=.active transition, shows an alert listing the failures, and
posts a UIAccessibility.announcement so VoiceOver users hear the result
even if focus is elsewhere.

New localization keys (English-only across all 26 locales, matching
the rest of the fork's localization pattern):
 - share_import_failure_alert_title
 - share_import_failure_copy_failed
 - share_import_failure_move_failed
 - share_import_failure_http_status
 - share_import_failure_announcement_multiple
Two related share-extension hardenings landing together because they
touch the same two download delegates (the in-process coordinator in
the share extension and BackgroundShareDownloadDelegate in the main
app):

H14 — Concurrent shares of the same URL used to collide because the
delegates wrote to `sharedFolder/{filename}` after a try? removeItem.
Second-completing share would clobber the first. Switch to a
UUID-prefixed destination so each share lands at its own filename.

H16 — A non-2xx HTTP response was the only rejection — an HTML
interstitial or error JSON served with 200 still got moved into the
library, where playback later produced cryptic codec errors. Add a
layered MIME check via the new ShareDownloadSupport helper:
  - Hard-reject text/html, text/plain, application/json,
    application/xml, application/problem+json — these are never an
    audiobook file.
  - Accept audio/*, video/*, application/zip, application/x-mpegurl.
  - Ambiguous types (application/octet-stream, application/download,
    missing MIME) only pass if the filename has a recognized media
    extension.

Rejections feed the share-import failure store with a specific,
actionable message ("returned content type X, which isn't an
audiobook file; try the direct download link") rather than the
generic "download failed."

Also harden filename derivation: strip path components, leading dots,
"..", control chars, cap length, and fall back to a UUID name if the
result is empty. Matters more for the path-traversal defense (per the
audit's C6) than for collisions.

New localization keys (English literals across all 26 .lproj files):
 - share_import_failure_unsupported_type
 - share_import_failure_unsupported_extension
Tapping Cancel in the share sheet only fired `cancelRequest(...)` on
the extension context — but by then iOS had already handed the
download task off to `nsurlsessiond`, which keeps running across
extension teardown. Best-effort `task.cancel()` from the extension
worked while the extension was alive but unreliable as iOS reclaimed
the process, so the main app would routinely receive a completion
event for a download the user thought they aborted, and move the
resulting file into the library anyway.

Two-layer fix:
 1. Best-effort fast path: store active download tasks in the share
    extension, call `.cancel()` on each before completeRequest. Wins
    when the extension is still alive.
 2. Durable marker: ShareCancelStore (file-backed App Group set,
    same pattern as ShareImportFailureStore) records the share id
    for every canceled task. Both the share-extension coordinator
    and the main app's BackgroundShareDownloadDelegate check the
    store before processing a completion — if the share id is in
    the canceled set, the temp file is removed and the import is
    skipped. Wins even if iOS killed the extension before its
    task.cancel propagated.

Each share download is now tagged with a stable UUID via
`URLSessionTask.taskDescription`, which iOS preserves across
extension teardown, giving both delegates a way to correlate the
later completion with the cancel signal.
Resolve Localizable.strings conflicts across all 26 locales by keeping
both sides' keys: upstream's new integration strings (integration_retry_button
et al. from the translations update) alongside the share-import keys this
branch adds. Code files merged cleanly; no logic changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant